package org.cyanogenmod.babel; import android.accessibilityservice.AccessibilityService; import android.accessibilityservice.AccessibilityServiceInfo; import android.accounts.Account; import android.accounts.AccountManager; import android.app.Activity; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.ComponentName; import android.content.ContentValues; import android.content.Intent; import android.content.SharedPreferences; import android.net.Uri; import android.os.Bundle; import android.os.IBinder; import android.os.UserHandle; import android.provider.Settings; import android.text.TextUtils; import android.util.Log; import android.view.accessibility.AccessibilityEvent; import com.android.internal.telephony.ISms; import com.google.gson.JsonObject; import com.google.gson.annotations.SerializedName; import com.koushikdutta.async.http.Multimap; import com.koushikdutta.ion.Ion; import com.koushikdutta.ion.Response; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.HashSet; import java.util.List; import java.util.PriorityQueue; import java.util.Set; import java.util.concurrent.ExecutionException; /** * Created by koush on 7/5/13. */ public class BabelService extends AccessibilityService { private static final String LOGTAG = "Babel"; private static final char ENABLED_ACCESSIBILITY_SERVICES_SEPARATOR = ':'; private ISms smsTransport; private SharedPreferences settings; // check which accessibility services are enabled private Set<ComponentName> getEnabledServicesFromSettings() { String enabledServicesSetting = Settings.Secure.getString(getContentResolver(), Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES); if (enabledServicesSetting == null) { enabledServicesSetting = ""; } Set<ComponentName> enabledServices = new HashSet<ComponentName>(); TextUtils.SimpleStringSplitter colonSplitter = new TextUtils.SimpleStringSplitter(ENABLED_ACCESSIBILITY_SERVICES_SEPARATOR); colonSplitter.setString(enabledServicesSetting); while (colonSplitter.hasNext()) { String componentNameString = colonSplitter.next(); ComponentName enabledService = ComponentName.unflattenFromString( componentNameString); if (enabledService != null) { enabledServices.add(enabledService); } } return enabledServices; } // ensure that this accessibility service is enabled. // the service watches for google voice notifications to know when to check for new // messages. private void ensureEnabled() { Set<ComponentName> enabledServices = getEnabledServicesFromSettings(); ComponentName me = new ComponentName(this, getClass()); if (enabledServices.contains(me) && connected) return; enabledServices.add(me); // Update the enabled services setting. StringBuilder enabledServicesBuilder = new StringBuilder(); // Keep the enabled services even if they are not installed since we // have no way to know whether the application restore process has // completed. In general the system should be responsible for the // clean up not settings. for (ComponentName enabledService : enabledServices) { enabledServicesBuilder.append(enabledService.flattenToString()); enabledServicesBuilder.append(ENABLED_ACCESSIBILITY_SERVICES_SEPARATOR); } final int enabledServicesBuilderLength = enabledServicesBuilder.length(); if (enabledServicesBuilderLength > 0) { enabledServicesBuilder.deleteCharAt(enabledServicesBuilderLength - 1); } Settings.Secure.putString(getContentResolver(), Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES, enabledServicesBuilder.toString()); // Update accessibility enabled. Settings.Secure.putInt(getContentResolver(), Settings.Secure.ACCESSIBILITY_ENABLED, 0); Settings.Secure.putInt(getContentResolver(), Settings.Secure.ACCESSIBILITY_ENABLED, 1); } // hook into sms manager to be able to synthesize SMS events. // new messages from google voice get mocked out as real SMS events in Android. private void registerSmsMiddleware() { try { Class sm = Class.forName("android.os.ServiceManager"); Method getService = sm.getMethod("getService", String.class); smsTransport = ISms.Stub.asInterface((IBinder)getService.invoke(null, "isms")); } catch (Exception e) { Log.e(LOGTAG, "register error", e); } } @Override public void onCreate() { super.onCreate(); settings = getSharedPreferences("settings", MODE_PRIVATE); registerSmsMiddleware(); clearGoogleVoiceNotifications(); } boolean connected; @Override public boolean onUnbind(Intent intent) { connected = false; return super.onUnbind(intent); } // set the accessibility filter to // watch only for google voice notifications @Override protected void onServiceConnected (){ super.onServiceConnected(); connected = true; AccessibilityServiceInfo info = new AccessibilityServiceInfo(); // We are interested in all types of accessibility events. info.eventTypes = AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED; // We want to provide specific type of feedback. info.feedbackType = AccessibilityServiceInfo.FEEDBACK_GENERIC; // We want to receive events in a certain interval. info.notificationTimeout = 100; // We want to receive accessibility events only from certain packages. info.packageNames = new String[] { Helper.GOOGLE_VOICE_PACKAGE }; setServiceInfo(info); } // parse out the intent extras from android.intent.action.NEW_OUTGOING_SMS // and send it off via google voice void handleOutgoingSms(Intent intent) { boolean multipart = intent.getBooleanExtra("multipart", false); String destAddr = intent.getStringExtra("destAddr"); String scAddr = intent.getStringExtra("scAddr"); ArrayList<String> parts = intent.getStringArrayListExtra("parts"); ArrayList<PendingIntent> sentIntents = intent.getParcelableArrayListExtra("sentIntents"); ArrayList<PendingIntent> deliveryIntents = intent.getParcelableArrayListExtra("deliveryIntents"); onSendMultipartText(destAddr, scAddr, parts, sentIntents, deliveryIntents, multipart); } @Override public int onStartCommand(final Intent intent, int flags, int startId) { super.onStartCommand(intent, flags, startId); if (null != settings.getString("account", null)) { ensureEnabled(); } if (intent == null) return START_STICKY; // handle an outgoing sms on a background thread. if (intent.getAction() == "android.intent.action.NEW_OUTGOING_SMS") { new Thread() { @Override public void run() { handleOutgoingSms(intent); } }.start(); } return START_STICKY; } // mark all sent intents as failures public void fail(List<PendingIntent> sentIntents) { if (sentIntents == null) return; for (PendingIntent si: sentIntents) { if (si == null) continue; try { si.send(); } catch (Exception e) { } } } // mark all sent intents as successfully sent public void success(List<PendingIntent> sentIntents) { if (sentIntents == null) return; for (PendingIntent si: sentIntents) { if (si == null) continue; try { si.send(Activity.RESULT_OK); } catch (Exception e) { } } } // fetch the weirdo opaque token google voice needs... void fetchRnrSe(String authToken) throws ExecutionException, InterruptedException { JsonObject json = Ion.with(this) .load("https://www.google.com/voice/request/user") .setHeader("Authorization", "GoogleLogin auth=" + authToken) .asJsonObject() .get(); String rnrse = json.get("r").getAsString(); settings.edit() .putString("_rns_se", rnrse) .commit(); } // mark an outgoing text as recently sent, so if it comes in via // round trip, we ignore it. PriorityQueue<String> recentSent = new PriorityQueue<String>(); private void addRecent(String text) { while (recentSent.size() > 20) recentSent.remove(); recentSent.add(text); } // send an outgoing sms event via google voice public void onSendMultipartText(String destAddr, String scAddr, List<String> texts, final List<PendingIntent> sentIntents, final List<PendingIntent> deliveryIntents, boolean multipart) { // grab the account and wacko opaque routing token thing String rnrse = settings.getString("_rns_se", null); String account = settings.getString("account", null); String authToken; try { // grab the auth token Bundle bundle = AccountManager.get(this).getAuthToken(new Account(account, "com.google"), "grandcentral", true, null, null).getResult(); authToken = bundle.getString(AccountManager.KEY_AUTHTOKEN); if (rnrse == null) { fetchRnrSe(authToken); rnrse = settings.getString("_rns_se", null); } } catch (Exception e) { Log.e(LOGTAG, "Error fetching tokens", e); fail(sentIntents); return; } // combine the multipart text into one string StringBuilder textBuilder = new StringBuilder(); for (String text: texts) { textBuilder.append(text); } String text = textBuilder.toString(); try { // send it off, and note that we recently sent this message // for round trip tracking sendRnrSe(authToken, rnrse, destAddr, text); addRecent(text); success(sentIntents); return; } catch (Exception e) { Log.d(LOGTAG, "send error", e); } try { // on failure, fetch info and try again fetchRnrSe(authToken); rnrse = settings.getString("_rns_se", null); sendRnrSe(authToken, rnrse, destAddr, text); addRecent(text); success(sentIntents); } catch (Exception e) { Log.d(LOGTAG, "send failure", e); fail(sentIntents); } } // hit the google voice api to send a text void sendRnrSe(String authToken, String rnrse, String number, String text) throws Exception { JsonObject json = Ion.with(this) .load("https://www.google.com/voice/sms/send/") .setHeader("Authorization", "GoogleLogin auth=" + authToken) .setBodyParameter("phoneNumber", number) .setBodyParameter("sendErrorSms", "0") .setBodyParameter("text", text) .setBodyParameter("_rnr_se", rnrse) .asJsonObject() .get(); if (!json.get("ok").getAsBoolean()) throw new Exception(json.toString()); } public static class Payload { @SerializedName("messageList") public ArrayList<Conversation> conversations = new ArrayList<Conversation>(); } public static class Conversation { @SerializedName("children") public ArrayList<Message> messages = new ArrayList<Message>(); } public static class Message { @SerializedName("startTime") public long date; @SerializedName("phoneNumber") public String phoneNumber; @SerializedName("message") public String message; // 10 is incoming // 11 is outgoing @SerializedName("type") int type; } private static final int VOICE_INCOMING_SMS = 10; private static final int VOICE_OUTGOING_SMS = 11; private static final int PROVIDER_INCOMING_SMS = 1; private static final int PROVIDER_OUTGOING_SMS = 2; // insert a message into the sms/mms provider. // we do this in the case of outgoing messages // that were not sent via this phone, and also on initial // message sync. void insertMessage(String number, String text, int type, long date) { ContentValues values = new ContentValues(); values.put("address", number); values.put("body", text); values.put("type", type); values.put("date", date); values.put("read", 1); getContentResolver().insert(Uri.parse("content://sms/sent"), values); } // refresh the messages that were on the server void refreshMessages() { String account = settings.getString("account", null); if (account == null) return; try { // tokens! Bundle bundle = AccountManager.get(this).getAuthToken(new Account(account, "com.google"), "grandcentral", true, null, null).getResult(); String authToken = bundle.getString(AccountManager.KEY_AUTHTOKEN); Payload payload = Ion.with(this) .load("https://www.google.com/voice/request/messages") .setHeader("Authorization", "GoogleLogin auth=" + authToken) .as(Payload.class) .get(); ArrayList<Message> all = new ArrayList<Message>(); for (Conversation conversation: payload.conversations) { for (Message message: conversation.messages) all.add(message); } // sort by date order so the events get added in the same order Collections.sort(all, new Comparator<Message>() { @Override public int compare(Message lhs, Message rhs) { if (lhs.date == rhs.date) return 0; if (lhs.date > rhs.date) return 1; return -1; } }); long timestamp = settings.getLong("timestamp", 0); boolean first = timestamp == 0; long max = timestamp; for (Message message: all) { max = Math.max(max, message.date); if (message.phoneNumber == null) continue; if (message.date <= timestamp) continue; if (message.message == null) continue; // on first sync, just populate the mms provider... // don't send any broadcasts. if (first) { int type; if (message.type == VOICE_INCOMING_SMS) type = PROVIDER_INCOMING_SMS; else if (message.type == VOICE_OUTGOING_SMS) type = PROVIDER_OUTGOING_SMS; else continue; // just populate the content provider and go insertMessage(message.phoneNumber, message.message, type, message.date); continue; } // sync up outgoing messages if (message.type == VOICE_OUTGOING_SMS) { boolean found = false; for (String recent: recentSent) { if (TextUtils.equals(recent, message.message)) { recentSent.remove(message.message); found = true; break; } } if (!found) insertMessage(message.phoneNumber, message.message, PROVIDER_OUTGOING_SMS, message.date); continue; } if (message.type != VOICE_INCOMING_SMS) continue; ArrayList<String> list = new ArrayList<String>(); list.add(message.message); try { // synthesize a BROADCAST_SMS event smsTransport.synthesizeMessages(message.phoneNumber, null, list, message.date); } catch (Exception e) { e.printStackTrace();; } } settings.edit() .putLong("timestamp", max) .commit(); } catch (Exception e) { Log.e(LOGTAG, "Error refreshing messages", e); } } // clear the google voice notification so the user doesn't get double notified. Object internalNotificationService; Method cancelAllNotifications; int userId; private void clearGoogleVoiceNotifications() { try { if (cancelAllNotifications == null) { // run this to get the internal service to populate NotificationManager nm = (NotificationManager)getSystemService(NOTIFICATION_SERVICE); nm.cancelAll(); Field f = NotificationManager.class.getDeclaredField("sService"); f.setAccessible(true); internalNotificationService = f.get(null); cancelAllNotifications = internalNotificationService.getClass().getDeclaredMethod("cancelAllNotifications", String.class, int.class); userId = (Integer)UserHandle.class.getDeclaredMethod("myUserId").invoke(null); } if (cancelAllNotifications != null) cancelAllNotifications.invoke(internalNotificationService, Helper.GOOGLE_VOICE_PACKAGE, userId); } catch (Exception e) { Log.d(LOGTAG, "Error clearing GoogleVoice notifications", e); } } void startRefresh() { needsRefresh = true; // if a sync is in progress, dont start another if (refreshThread != null && refreshThread.isAlive()) return; refreshThread = new Thread() { @Override public void run() { while (needsRefresh) { needsRefresh = false; refreshMessages(); } } }; refreshThread.start(); } boolean needsRefresh; Thread refreshThread; @Override public void onAccessibilityEvent(AccessibilityEvent event) { if (event.getEventType() != AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED) return; if (!Helper.GOOGLE_VOICE_PACKAGE.equals(event.getPackageName())) return; clearGoogleVoiceNotifications(); startRefresh(); } @Override public void onInterrupt() { } }